Go back home
Making a blogging system with Phoenix and React [Part 3]

Making a blogging system with Phoenix and React [Part 3]

making-a-blogging-system-with-phoenix-and-react-[part-3]

Feature images

Welcome to this third part.
In the last post, we scaffolded our blog posts and made sure that Milkdown integrated with the Phoenix form.

Today, we're going to take care of our feature image. To do this, we need two hex packages. Add the following to your mix.exs:

 #mix.exs
 {
 #... your other packages,
  {:waffle, "~> 1.1"},
  {:waffle_ecto, "~> 0.0"},
  {:ex_aws, "~> 2.1.2"}, # Skip if using local storage
  {:ex_aws_s3, "~> 2.0"},# Skip if using local storage
  {:hackney, "~> 1.9"}, # Skip if using local storage
  {:sweet_xml, "~> 0.6"},# Skip if using local storage
 }

then run the usual mix deps.get.

I will be uploading images to my DigitalOcean storage space but if you're following along, feel free to use your local storage.

What is Waffle ?

Waffle, a library, exposes useful helpers that will allow us to rename, verify, crop, transform, version and save our feature image.
In my use case, I shrink the image size way down for the thumbnail version but leave the original untouched.

Waffle Ecto lets us save the reference to our images right in our form instead of handling it ourselves.
Let's get started:

Preparing file uploads

Waffle and Waffle Ecto need a little setup in order to be functional.
You can read most of it in their respective documentation :

Waffle docs

Waffle Ecto

Setting up Waffle

The config

The first thing to do is setting up the config.
If you are going to use local storage, you only need this part :

config :waffle,
  storage: Waffle.Storage.Local,
  asset_host: {:system, "ASSET_HOST"}

If, like me, you are going to use a file hosting service like DigitalOcean, then you'll have a few extra steps to follow .

To create the config, we'll need to retrieve your DigitalOcean's Space :

  • private and public keys

  • bucket name

  • host ("https://{bucket_name}.{region}.digitaloceanspaces.com")

It might look like this:

DO_SPACES_PUBLIC_KEY=my_public_key
DO_SPACES_SECRET_KEY=my_secret_key
AWS_S3_BUCKET= my-bucket
ASSET_HOST=https://my-bucket.syd1.digitaloceanspaces.com
EX_AWS_HOST=my-bucket.syd1.digitaloceanspaces.com
EX_AWS_REGION="us-east-1"

Then in our config.ex :

# Configures file uploads to DigitalOcean
config :waffle,
  storage: Waffle.Storage.S3,
  bucket: {:system, "AWS_S3_BUCKET"},
  asset_host: {:system, "ASSET_HOST"}

config :ex_aws,
  debug_requests: true,
  json_codec: Jason,
  access_key_id: {:system, "DO_SPACES_PUBLIC_KEY"},
  secret_access_key: {:system, "DO_SPACES_SECRET_KEY"}

config :ex_aws, :s3,
  scheme: "https://",
  host: {:system, "EX_AWS_HOST"},
  region: {:system, "EX_AWS_REGION"}

Why does it say "AWS" if we're using Digital Ocean ?

  1. Because I used the given config and did not feel like changing them.

  2. Because Digital Ocean's API is close to AWS' own convention. See here : https://docs.digitalocean.com/reference/api/spaces-api/#aws-s3-compatibility

The :s3 variable EX_AWS_REGION needs to be set to an AWS region ("us-east-1"). This will only be used when creating a new bucket, for verification purposes. Any other API call will go to the EX_AWS_HOST we specified.

Scaffolding the file

Now that our base config is done, it is time to create our Waffle files. This can be done by using the command mix waffle.g feature_image.

In our lib/app_web/ folder we'll find a new folder called uploaders inside which lives feature_image.ex file. This is where all the upload transformation, naming etc will happen.

Let's open it and add some modifications.

defmodule App.FeatureImage do
  use Waffle.Definition
  # Include ecto support (requires package waffle_ecto installed):
+  use Waffle.Ecto.Definition

+  @versions [:original, :thumb]
+  @acl :public_read
  # ... Things I would care about if this wasn't self hosted
end

What's happening here ?

  1. We're letting Waffle know that it should work in conjunction with Waffle.Ecto.

  2. We specify the two versions we want to create for each uploaded file

  3. We set the default read permissions to public on the files that get uploaded

Let's keep configuring our uploads :

defmodule App.FeatureImage do
  #[...]
  # Define a thumbnail transformation:
+  def transform(:thumb, _) do
+   {:convert, "-strip -thumbnail 100x100^ -gravity center -extent 100x100"}
+ end
+  def transform(:original, _) do
+   {:convert, "-trim -resize 600x400 -gravity center -extent 600x400"}
+ end
+ # Override the persisted filenames:
+  def filename(version, {file, scope}) do
+   file_name = Path.basename(file.file_name, Path.extname(file.file_name))
+   "#{scope.id}_#{version}_#{file_name}"
+  end

  # If you're using local storage this one is for you :
  # Override the storage directory:
  # def storage_dir(version, {file, scope}) do
  #   "uploads/user/avatars/#{scope.id}"
  # end

  # Provide a default URL if there hasn't been a file uploaded
+  def default_url(_version, _scope) do
+    "https://my-bucket.region.cdn.digitaloceanspaces.com/uploads/no_image_found.png"
+  end
   #[more code here]
   # Only for online hosting:
+  def s3_object_headers(_version, {file, _scope}) do
+     [content_type: MIME.from_path(file.file_name)]
+   end



In this snippet we :

  • define the shapes and sizes of our different image uploads with transform/2, which uses imagemagick under the hood

  • give the file a name based on the resource created by Ecto (our scope)

  • set a default image in case none was uploaded for our post

  • infer the MIMEtype of our content thanks to MIME.from_path/1

Awesome ! Now that this is all sorted though, we need to let Ecto know what's happening and how to deal with it.

Modifying the schema and changeset

Let's head to our /lib/app/blog/post.ex file and change some things.


defmodule App.Blog.Post do
  use Ecto.Schema
  import Ecto.Changeset
+ use Waffle.Ecto.Schema

  schema "posts" do
    field :title, :string
    field :content, :string
    field :published_at, :naive_datetime
    field :tags, :string
-   field :feature_image, :string
+   field :feature_image, Ownidevapi.FeatureImage.Type

    timestamps(type: :utc_datetime)
  end

 @doc false
 def changeset(post, attrs) do
   post
-   |> cast(attrs, [:title, :content, :published_at,:tags, :feature_image])
+   |> cast(attrs, [:title, :content, :published_at,:tags])
-   |> validate_required([:title, :content, :published_at, :tags, :feature_image])
+  |> validate_required([:title, :content, :published_at, :tags])
 end

+ def feature_image_changeset(post, attrs) do
+   post
+   |> cast_attachments(attrs, [:feature_image], allow_paths: true)
+   |> validate_required([:feature_image])
+ end
end

Here we use a new type of schema, provided by Waffle Ecto, that will allow us to store the reference to our uploaded file.
Since we want to reference our images with the ID of the post, we have to modify our changesets. changeset/2 will handle the initial insert of the post, hence why we remove :feature_image as a required field and why we don't bother casting it (it would fail anyway). feature_image_changeset/2 will be the one who kickstarts the upload process with cast_attachments/4. Since we're temporary saving the file on our server, we need to make sure to pass the allow_path option.

Changing our insert method

Since we added that changeset, we need to use it somewhere.
Globally, what we want is to :

  1. Insert our new post (without the image) into our database

  2. Get this new post's id

  3. Upload the image to our service, named using the post id

  4. Update our post to contain the reference to that image in the service

Ecto offers a great way to do this with Ecto.Multi. Let's head over to our lib/app/blog.ex,find the create_post/1 method and completely modify it as follows :

  def create_post(attrs \\ %{}) do
    Ecto.Multi.new() # Start a new transaction
    |> Ecto.Multi.insert(:post, Post.changeset(%Post{}, attrs)) # Insert our post using our normal changeset
    |> Ecto.Multi.update(:post_with_image, &Post.feature_image_changeset(&1.post, attrs)) # Grab the newly created post and pass it through our new changeset with the same attrs (containing the path of our image)
    |> Repo.transaction() # Run the transaction
    |> case do
      {:ok, %{post_with_image: post}} -> {:ok, post}
      {:error, _, changeset, _} -> {:error, changeset}
    end
  end

Notice how we really only had to do part 1 ourselves and how our changeset and the cast_attachments/3 took care of everything else.

We can go ahead and modify our update_post/2 method, though since the post exist we can just pipe through our changesets:

  def update_post(%Post{} = post, attrs) do
    post
    |> Post.changeset(attrs)
    |> Post.feature_image_changeset(attrs)
    |> Repo.update()
  end

Enabling image uploads on the form

The next big part of this is to enable us to upload that file through the post form.

The form input

Phoenix LiveView gives us a component called live_file_input that will allow us to consume the file into our saving process.
We'll open our lib/app_web/live/post_live/form_component.ex where most of this is being done.

Let's replace the basic text input by the component, as well as a few helpers. I'm not reinventing the wheel here, for the purpose of this guide this can all be found in the Phoenix Documentation1


defmodule AppWeb.PostLive.FormComponent do
  use AppWeb, :live_component
  alias App.Blog
  @impl true
  def render(assigns) do

    ~H"""
    <div>
      <.header>
        <%= @title %>
        <:subtitle>Use this form to manage post records in your database.</:subtitle>
      </.header>

      <.simple_form
        for={@form}
        id="post-form"
        phx-target={@myself}
        phx-change="validate"
        phx-submit="saved"
      >

        <.input field={@form[:title]} type="text" label="Title" />
        <.input field={@form[:content]} type="textarea" label="Content" />
        <.input field={@form[:published_at]} type="datetime-local" label="Published at" />
        <.input field={@form[:tags]} type="text" label="Tags" />
-       <.input field={@form[:feature_image]} type="text" label="Feature Image"/>
+         <div>
+             <.live_file_input upload={@uploads.feature_image} />
+               <section phx-drop-target={@uploads.feature_image.ref}>

+               <%= for entry <- @uploads.feature_image.entries do %>
+                 <article class="upload-entry">

+                   <figure>
+                     <.live_img_preview entry={entry} />
+                     <figcaption><%= entry.client_name %></figcaption>
+                   </figure>
+                   <progress value={entry.progress} max="100"> <%= entry.progress %>% </progress>
+                   <button type="button" phx-click="cancel-upload" phx-value-ref={entry.ref} aria-label="cancel">&times;</button>
+                   <%= for err <- upload_errors(@uploads.feature_image, entry) do %>
+                     <p class="alert alert-danger"><%= error_to_string(err) %></p>
+                   <% end %>
+                 </article>
+               <% end %>
+               <%= for err <- upload_errors(@uploads.feature_image) do %>
+                 <p class="alert alert-danger"><%= error_to_string(err) %></p>
+               <% end %>

+               </section>

+        </div>

        <:actions>
          <.button phx-disable-with="Saving...">Save Post</.button>
        </:actions>
      </.simple_form>
      <div>
      """
    # ... The mount functions etc
    end

Simply enough, we add a file input that will allow the user (us in this case) to upload a file. We loop over the entries (even though there is only 1), display the image, its upload progress as well as whatever errors may arise.
That's it for the DOM, now to make it functional.

The functions

The first thing to do in this same file is to tell Phoenix that we are expecting uploads.
In the same form_component.ex as before, we have to modify the socket :

defmodule App.PostLive.FormComponent do

 #... the form hEex
  @impl true
  def update(%{post: post} = assigns, socket) do
    changeset = Blog.change_post(post)

    {:ok,
     socket
     |> assign(assigns)
     |> assign_form(changeset)
+    |> allow_upload(:feature_image, accept: ~w(.jpg .jpeg .png), max_entries: 1)
    }
  end
end

This does what it says on the tin and allows the upload of 1 entry of type .jpg, .jpeg or.png.
Now let's setup our event handlers. We need to handle the "saved" event for both creation and update. The out of the box "validate" can stay the same.

defmodule App.PostLive.FormComponent do

 #... the form hEex
  @impl true
  def update(%{post: post} = assigns, socket) do
    changeset = Blog.change_post(post)

    {:ok,
     socket
     |> assign(assigns)
     |> assign_form(changeset)
    |> allow_upload(:feature_image, accept: ~w(.jpg .jpeg .png), max_entries: 1)
    }
  end
    #... The validate method

  @impl true
  def handle_event("saved", %{"post" => params}, %{:assigns => %{:uploads => %{:feature_image => image}}}=socket)  when image.entries !== [] do
    [uploaded_file] =
      consume_uploaded_entries(socket, :feature_image, fn %{path: path}, entry ->
        dest = Path.join(Application.app_dir(:ownidevapi, "priv/static/uploads"), "post_image" <> Path.extname(entry.client_name))
        File.cp!(path, dest)
        {:ok, dest}
      end)
    save_post(socket, socket.assigns.action, Map.put(params, "feature_image", uploaded_file))
  end
  def handle_event("saved", %{"post" => params}, socket) do
    save_post(socket, socket.assigns.action, params)
  end

  # This should have been created for you by Phoenix gen
  defp save_post(socket, :edit, post_params) do
    case Blog.update_post(socket.assigns.post, post_params) do
      {:ok, post} ->
        notify_parent({:saved, post})

        {:noreply,
         socket
         |> put_flash(:info, "Post updated successfully")
         |> push_patch(to: socket.assigns.patch)}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign_form(socket, changeset)}
    end
  end

  @impl true
  defp save_post(socket, :new, post_params) do
    case Blog.create_post(post_params) do
      {:ok, post} ->
        notify_parent({:saved, post})

        {:noreply,
         socket
         |> put_flash(:info, "Post created successfully")
         |> push_patch(to: socket.assigns.patch)}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign_form(socket, changeset)}
    end
  end

  defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
end

Our "saved" events handle either a post with a featured_image or a post without (which will fail as it is required).
The first definition grabs the uploaded image and saves it under a new name to an upload folder. We then modify the value of "feature_image" in our post params to reflect the uploaded image.
Both of the handle_event/2 then pass the post params to the save_post/3 private function that will call a different method based on the current action (:new post or :edit post).

Finally, let's handle errors, always in the same file :

defmodule AppWeb.Blog.PostLive do
    # The rest of your code...
  defp error_to_string(:too_large), do: "Too large"
  defp error_to_string(:too_many_files), do: "You have selected too many files"
  defp error_to_string(:not_accepted), do: "You have selected an unacceptable file type"
  end

And this concludes the "feature image" part of the blog posts!
In the next post, we will scaffold API routes and functionalities.
See you then :)

1

Phoenix documentation on file uploads : https://hexdocs.pm/phoenix/1.7.10/file_uploads.html